uno: RsaUnwrap bring-up — OAEP endianness fix + non-CRT RSA private-key import - #602
uno: RsaUnwrap bring-up — OAEP endianness fix + non-CRT RSA private-key import#602Rajib Dutta (radutta99) wants to merge 16 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Brings up RSA unwrap support on uno firmware by aligning RSA-OAEP decode with the PKA’s little-endian behavior, adding non-CRT RSA private-key import from DER into the uno vault layout, and tightening an OAEP error contract to match the std PAL.
Changes:
- Reverse the
mod_exp_privresult (LE→BE) before OAEP unpadding to fix RSA-OAEP decrypt on uno. - Implement
rsa_priv_der_to_vaultfor non-CRT RSA keys via a minimal PKCS#8/PKCS#1 DER parser and vault operand assembly, plus stack scratch scrubbing. - Return
RsaInvalidKeyLength(notInvalidArg) when OAEP plaintext exceeds the caller buffer; addzeroizedependency for firmware PAL.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| fw/plat/uno/fw/pal/src/crypto/rsa.rs | Adds DER parsing + non-CRT RSA private-key import; fixes OAEP endianness and output-length error contract. |
| fw/plat/uno/fw/pal/Cargo.toml | Adds zeroize as a dependency for the uno PAL crate. |
| fw/plat/uno/fw/Cargo.toml | Adds zeroize (no-default-features) to the uno firmware workspace dependencies. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
fw/plat/uno/fw/pal/src/crypto/rsa.rs:175
- In the PKCS#8 path, the code descends into the
privateKeyOCTET STRING but does not verify that the innerRSAPrivateKeySEQUENCE consumes the entire OCTET STRING. Without that check, extra bytes inside the OCTET STRING are silently accepted.
let (ot, oct_start, _ol, _on) = der_tlv(der, after_alg)?;
if ot != 0x04 {
return None;
}
let (st, inner_start, _sl, _sn) = der_tlv(der, oct_start)?;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
fw/plat/uno/fw/pal/src/crypto/rsa.rs:332
buf[vault_len..].zeroize()scrubs a plain&mut [u8]using thezeroizecrate, but this repo has a DMA-specific wipe primitive (DmaBuf::zeroize) that uses per-byte volatile writes + a compiler fence (fw/pal/traits/src/alloc.rs:148-160). For DMA-backed buffers, prefer wiping viaDmaBuf::zeroizeto match the established secret-scrub pattern.
buf[vault_len..].zeroize();
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
fw/plat/uno/fw/pal/src/crypto/rsa.rs:199
parse_rsa_priv_dercurrently only parses the PKCS#1version,n,e,dintegers and then returns success without confirming that the mandatory remaining PKCS#1 fields (p,q,dp,dq,qinv) are present and that the key SEQUENCE is fully consumed. This makes the parser accept malformed/truncated RSA keys (or extra trailing bytes inside the SEQUENCE) even though the comment says those fields “follow”, and it weakens input validation for untrusted recovered key material.
// n, e, d in order (p, q, dp, dq, qinv follow but are unused for non-CRT).
let (ns, nl, after_n) = der_int(der, p)?;
let (es, el, after_e) = der_int(der, after_n)?;
let (ds, dl, _after_d) = der_int(der, after_e)?;
Some(((ns, nl), (es, el), (ds, dl)))
uno's rsa_oaep_decrypt fed the LE ciphertext to the LE-native mod_exp_priv (correct) but then ran the RFC 8017 EME-OAEP decode directly on the LE result, checking em[0] (the LSB) for the 0x00 leading byte and MGF1-unmasking byte-reversed data -> RsaDecryptFailed for every real unwrap. Reverse the mod-exp result LE->BE first (matching the std PAL's flip around OpenSSL) so the OAEP decode sees the big-endian EM = 0x00 || maskedSeed || maskedDB.
Implement rsa_priv_der_to_vault on the uno PAL so RsaUnwrap can import a recovered RSA private key. Adds a no_std DER parser (der_tlv, der_int, parse_rsa_priv_der) handling PKCS#8 PrivateKeyInfo and bare PKCS#1 RSAPrivateKey. The PKCS#8 algorithm is validated to be rsaEncryption (OID 1.2.840.113549.1.1.1 + NULL params), mirroring mcr-hsm Asn1RsaEncryptionInfo. The parsed n/e/d are assembled into the PKA vault operand [d(k) || n(k) || e(4)] little-endian, matching mcr-hsm RsaPrivKey::to_pka_bytes and what rsa_priv_pub_key reads back. The stack scratch that holds the private exponent is scrubbed with zeroize (volatile writes, not elidable). Non-CRT only; CRT import returns UnsupportedCmd (needs PKA-derived n1q/n2p, deferred). HW-verified: unblocks rsa_unwrap_generated_key rsa_key, rsa_key_sizes, and incorrect_input_key_usage (12/17 in the suite now pass).
rsa_oaep_decrypt returned InvalidArg when the recovered message was larger than the caller output buffer. The std PAL returns RsaInvalidKeyLength here, and the shared key-unwrap path maps that to RsaUnwrapInvalidKek. Align uno with that contract so an oversized recovered KEK surfaces as RsaUnwrapInvalidKek. HW-verified: rsa_unwrap_smoke oversized_kek_smoke now passes (smoke 3/5; remaining 2 need ECC/CRT import).
Address PR review comments: der_int now rejects negative DER INTEGERs (MSB set on the first content byte with no 0x00 sign pad) and non-minimal encodings, since it parses untrusted recovered key material; the integer zero (PKCS#8/PKCS#1 version field) is still accepted. rsa_oaep_decrypt now scrubs the secret-bearing em DMA scratch via DmaBuf::zeroize on every exit path (including ?-propagated errors) by running the fallible OAEP decode in an inner block.
Address PR review: rsa_priv_der_to_vault now scrubs the tail of buf after rewriting the vault operand (the leftover source DER still holds secret CRT components p/q/dp/dq/qinv; the scoped DMA buffer is reused without automatic wiping). parse_rsa_priv_der now rejects trailing garbage: the outer SEQUENCE must span the entire input (der.len()), and the PKCS#8 privateKey OCTET STRING must wrap exactly the inner RSAPrivateKey SEQUENCE (material is exact-length per unwrap_key returning payload_buf[..payload_len]).
Address PR review: der_tlv now rejects non-minimal long-form definite lengths. The most-significant length byte must be non-zero (no leading-zero padding), and long form must not encode a length that fits in short form (len >= 0x80). This tightens the parser against malformed untrusted key material, matching strict DER decoders.
Address PR review: rsa_priv_der_to_vault returned early on crt==true, DER parse failure, and size validation without wiping buf, leaving the recovered plaintext RSA private-key DER resident in reused DMA SRAM. Now zeroize buf on every early-error return. The success path is unchanged (it overwrites buf[..vault_len] with the vault operand and scrubs buf[vault_len..]).
…tent Address PR review: (1) rsa_priv_der_to_vault now rejects (scrub + InvalidArg) when the vault operand length (2*modulus_len+4) exceeds buf.len(), which a malformed/truncated PKCS#1 (e.g. a very short d) could trigger, avoiding an out-of-bounds panic on the in-place slices. (2) parse_rsa_priv_der now requires the AlgorithmIdentifier NULL parameters to consume the rest of the algorithm SEQUENCE (null_next == after_alg), rejecting extra trailing fields.
0f88cf9 to
4c437bd
Compare
Drop the bare PKCS#1 RSAPrivateKey fallback in parse_rsa_private_key. The recovered RsaUnwrap wire key is always a PKCS#8 PrivateKeyInfo (the format the test collateral is generated in), so the untested bare-PKCS#1 path is removed, reducing the accepted-input surface. Addresses review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2444329e-909e-4ced-b5df-5a40fb65bb6c
Replace the >1 KiB stack scratch buffer with a fully in-place assembly: the vault operand [d|n|e] overlaps the DER field offsets it reads from, so d is first staged into the tail of buf (which the caller scrubs) via overlap-safe copy_within, then the fields are moved and reversed big-endian to little-endian into the front. Field offsets are recovered from the der UintRef sub-slices. Removes write_le, assemble_rsa_operand, MAX_RSA_MODULUS_LEN, and the now-unused HsmAlloc and RsaPrivateKeyAsn1 imports. Also adds a TODO on the CRT stub. Addresses review feedback. HW (Manticore EVB): rsa_unwrap non-CRT RSA import passes for 2k/3k/4k (rsa_key + rsa_key_sizes); 15/22 rsa_unwrap matches the baseline, no regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2444329e-909e-4ced-b5df-5a40fb65bb6c
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
fw/plat/uno/fw/pal/src/asn1.rs:103
parse_rsa_private_keycurrently rejects a bare PKCS#1RSAPrivateKey(it only accepts PKCS#8PrivateKeyInfo). This contradicts both the module docs ("PKCS#8 / PKCS#1") and the PR description, which says bare PKCS#1 is supported. Either add a PKCS#1 fallback parse or update the docs/PR description to match the actual accepted formats.
pub(crate) fn parse_rsa_private_key(der_bytes: &[u8]) -> Option<RsaPrivateKeyAsn1<'_>> {
// The recovered wire key is always a PKCS#8 PrivateKeyInfo (the format the
// RsaUnwrap collateral is generated in); a bare PKCS#1 RSAPrivateKey is not
// accepted.
let pki = RsaPrivateKeyInfo::from_der(der_bytes).ok()?;
- Move the `der` dependency to `[workspace.dependencies]` and reference it
as `der = { workspace = true }`, matching every other external crate in
the uno firmware workspace.
- Drop `RsaOperandLayout::e_len`. The vault operand's exponent slot is a
fixed `EXP_WIRE_LEN` field, so carrying both a 4-byte array and a length
expressed the same thing twice. `e` is now converted to its little-endian
wire form once, while parsing, and assembly is a single `copy_from_slice`
instead of copy/reverse/pad. `EXP_WIRE_LEN` is hoisted to module scope and
used everywhere the width was previously spelled `4`.
- Remove the explicit zeroize calls. The per-IO teardown scrub wipes the
whole slot, so these are redundant and would only have to be deleted
again; a comment records that the buffer is intentionally left dirty.
- Removing `em.zeroize()` also removes the reason for the inner `async`
block in `rsa_oaep_decrypt` — it existed solely to funnel every exit path
into one scrub — so the decode returns to a flat `?` flow.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2444329e-909e-4ced-b5df-5a40fb65bb6c
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
fw/plat/uno/fw/pal/src/asn1.rs:103
- The PR description and this module’s top-level docs claim the RSA decoder handles both PKCS#8
PrivateKeyInfoand bare PKCS#1RSAPrivateKey, butparse_rsa_private_keycurrently rejects bare PKCS#1 (“not accepted”). Either the docs/PR description should be updated, or the parser should accept PKCS#1 as a fallback to match the stated contract.
pub(crate) fn parse_rsa_private_key(der_bytes: &[u8]) -> Option<RsaPrivateKeyAsn1<'_>> {
// The recovered wire key is always a PKCS#8 PrivateKeyInfo (the format the
// RsaUnwrap collateral is generated in); a bare PKCS#1 RSAPrivateKey is not
// accepted.
let pki = RsaPrivateKeyInfo::from_der(der_bytes).ok()?;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
fw/plat/uno/fw/pal/src/crypto/rsa.rs:266
- This comment claims the recovered plaintext DER in
bufis scrubbed by a per-IO teardown, butUnoHsmIoController::drop_iocurrently only frees the slot (fw/plat/uno/fw/pal/src/io.rs:171-174) andreset_io_alloconly rewinds watermarks (fw/plat/uno/fw/pal/src/alloc.rs:94-98). As written, recovered RSA private key material can persist in the reused DMA buffer after both success and error paths.
// Note: `buf` holds recovered plaintext DER (and, after assembly, the
// staged `d` plus the leftover CRT components `p`/`q`/`dp`/`dq`/`qinv`)
// on every path through this function. It is not scrubbed here — the
// per-IO teardown scrub wipes the whole slot, so a local wipe would be
// redundant work that has to be removed again.
fw/plat/uno/fw/pal/src/crypto/rsa.rs:273
rsa_priv_der_to_vaultreturns early on CRT requests, parse failures, and size validation failures without scrubbingbuf, and the success path leaves the DER tail (including CRT components) resident in SRAM. Since IO teardown does not currently wipe IO buffers, this leaks plaintext private-key material across IO reuse.
if crt {
return Err(HsmError::UnsupportedCmd);
}
fw/plat/uno/fw/pal/src/asn1.rs:103
- The PR description says the DER parser accepts both PKCS#8
PrivateKeyInfoand bare PKCS#1RSAPrivateKey, but this function currently rejects bare PKCS#1 by unconditionally decodingRsaPrivateKeyInfofirst. Either update the behavior to fall back to PKCS#1, or adjust the PR description/docs to match.
pub(crate) fn parse_rsa_private_key(der_bytes: &[u8]) -> Option<RsaPrivateKeyAsn1<'_>> {
// The recovered wire key is always a PKCS#8 PrivateKeyInfo (the format the
// RsaUnwrap collateral is generated in); a bare PKCS#1 RSAPrivateKey is not
// accepted.
let pki = RsaPrivateKeyInfo::from_der(der_bytes).ok()?;
The endian reversal between DER/SEC1 big-endian integers and the PKA/vault little-endian layout was written out as a loop or as copy-then-reverse at every call site. Add a single `reverse_copy` in `crypto/mod.rs` and use it from its two consumers. In the RSA operand assembly it covers the public exponent, and the private exponent, whose staged copy is disjoint from the front of the buffer, so the previous copy-then-reverse-in-place collapses into one pass. The modulus keeps `copy_within` plus an in-place reverse because its source and destination can overlap and `reverse_copy` needs disjoint slices. Verified byte-identical to the previous assembly across 60 combinations of modulus size, private-exponent length and field offsets. It also folds the four copy-then-reverse pairs in the deterministic ECC signing path, which all copy between distinct buffers. The helper lives in the uno PAL rather than in `azihsm_fw_hsm_pal_traits`: it is a plain function, not part of any PAL contract, so a traits crate is the wrong home. Equivalent private copies remain in the std PAL and in the core evidence / key-report / attest-key handlers, which cannot reach a uno-local helper; deduplicating those would need a shared utility crate and is left alone here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2444329e-909e-4ced-b5df-5a40fb65bb6c
596b73e to
5f9a843
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
fw/plat/uno/fw/pal/src/crypto/rsa.rs:279
- Error exits from
rsa_priv_der_to_vault(crt, parse failure, size check) return without scrubbingbuf, which still contains recovered plaintext RSA private-key DER. SinceDmaBufcontents persist after the scoped allocator rewinds, this can leak key material in SRAM after a failed import.
if crt {
return Err(HsmError::UnsupportedCmd);
}
// Parse + validate + capture the field layout. The `key` borrow of `buf`
// (held by the decoded `UintRef`s) is released before the in-place
// rewrite below.
let Some(layout) = rsa_operand_layout(&buf[..]) else {
return Err(HsmError::InvalidArg);
};
fw/plat/uno/fw/pal/src/crypto/rsa.rs:290
- After in-place operand assembly,
buf[vault_len..]still contains stageddand leftover DER (including CRT components like p/q/dp/dq/qinv). Without a guaranteed per-IO scrub, this should be zeroized before returning.
assemble_rsa_operand_in_place(&mut buf[..], &layout);
Ok((vault_len, k))
fw/plat/uno/fw/pal/src/asn1.rs:103
parse_rsa_private_keycurrently rejects bare PKCS#1RSAPrivateKeyinputs (it only parses PKCS#8PrivateKeyInfo). This contradicts both the PR description (“PKCS#8 and bare PKCS#1”) and this module’s header doc that states PKCS#8 / PKCS#1 support.
pub(crate) fn parse_rsa_private_key(der_bytes: &[u8]) -> Option<RsaPrivateKeyAsn1<'_>> {
// The recovered wire key is always a PKCS#8 PrivateKeyInfo (the format the
// RsaUnwrap collateral is generated in); a bare PKCS#1 RSAPrivateKey is not
// accepted.
let pki = RsaPrivateKeyInfo::from_der(der_bytes).ok()?;
fw/plat/uno/fw/pal/src/crypto/rsa.rs:117
rsa_operand_layoutonly bounds-checkseby byte width, but it doesn’t validate the exponent value itself (e.g., it would accept an empty/zero exponent or an even exponent). Since this is decoding untrusted imported key material, reject invalid RSA public exponents to avoid importing unusable/invalid keys.
let n = key.modulus.as_bytes();
let e = key.public_exponent.as_bytes();
let d = key.private_exponent.as_bytes();
let modulus_len = n.len();
if !matches!(modulus_len, 256 | 384 | 512) || e.len() > EXP_WIRE_LEN || d.len() > modulus_len {
return None;
}
Summary
Brings up the RSA paths of
RsaUnwrapon the uno HSM firmware. Three changes:OAEP endianness fix —
rsa_oaep_decryptnow flips themod_exp_privresult LE→BE before the RFC 8017 EME-OAEP decode. The uno PKA is little-endian native (LE ciphertext in, LE result out), but the OAEP encoded message0x00 ‖ maskedSeed ‖ maskedDBis big-endian; the std PAL does the same flip around OpenSSL. Without it, every RsaUnwrap failed withRsaDecryptFailed (0x08700012). This fixes the AES-key unwrap path.Non-CRT RSA private-key import — implements
rsa_priv_der_to_vault. Adds a smallno_stdDER parser (der_tlv,der_int,parse_rsa_priv_der) handling PKCS#8PrivateKeyInfoand bare PKCS#1RSAPrivateKey. The PKCS#8AlgorithmIdentifieris validated to bersaEncryption(OID 1.2.840.113549.1.1.1 + NULL params), mirroring mcr-hsm'sAsn1RsaEncryptionInfo. The parsedn/e/dare assembled into the PKA vault operand[d(k) ‖ n(k) ‖ e(4)]little-endian — matching mcr-hsm'sRsaPrivKey::to_pka_bytesand whatrsa_priv_pub_keyreads back. The stack scratch holding the private exponent is scrubbed withzeroize. CRT import returnsUnsupportedCmd(deferred — needs PKA-derivedn1q/n2p).OAEP oversized-output error contract —
rsa_oaep_decryptnow returnsRsaInvalidKeyLength(notInvalidArg) when the recovered message exceeds the caller's output buffer, matching the std PAL. The shared key-unwrap path maps this toRsaUnwrapInvalidKek, so an oversized recovered KEK is now reported correctly.Testing
Hardware (Manticore EVB):
rsa_unwrap_generated_key: 12/17 (was 9/17)rsa_unwrap_smoke: 3/5 (was 2/5) —oversized_kek_smokenow passesget_unwrapping_key: 7/7 (unchanged)Emu:
precheck --nextest --package azihsm_ddi_mbor_types --features emu --filter smoke --profile ci-emu-smoke→ 94/94 passed.fmt / clippy (
-D warnings, ms-nightly) / taplo all clean.Deferred to follow-up PRs
The remaining
rsa_unwrapfailures each map cleanly to one of:n1q/n2p(mcr-hsm computes these separately).ecc_key_smoke,ecc_keys_with_key_tag.Entryalready carries asession_or_tagfield).Base
Stacked on #601 (
user/radutta/get-unwrapping-key), which is not yet merged — this PR targets that branch, notmain.